import browser from "webextension-polyfill"

// Ignore the no-console lint rule since this file is meant to funnel output to
// the console.
/* eslint-disable no-console */

const HOUR = 1000 * 60 * 60

const store = {
  async get(key: string): Promise<string> {
    const realKey = `logs-${key}`
    return String((await browser.storage.local.get(realKey))[realKey]) ?? ""
  },
  async getAll<T extends string[], Keys extends T[number]>(
    ...keys: Keys[]
  ): Promise<{ [key in Keys]: string }> {
    const keyResults = await browser.storage.local.get(
      keys.map((key) => `logs-${key}`),
    )
    return Object.fromEntries(
      keys.map<[Keys, string]>((key) => [
        key,
        String(keyResults[`logs-${key}`]) ?? "",
      ]),
    ) as { [key in Keys]: string }
  },
  async set(key: string, value: string): Promise<void> {
    browser.storage.local.set({ [`logs-${key}`]: value })
  },
}

export enum LogLevel {
  debug = "debug",
  info = "info",
  warn = "warn",
  error = "error",
  off = "off",
}

const logLevels = {
  [LogLevel.debug]: 0,
  [LogLevel.info]: 1,
  [LogLevel.warn]: 2,
  [LogLevel.error]: 3,
  [LogLevel.off]: 4,
}

interface LogStyle {
  icon: string
  css: string[]
  dateCss?: string[]
}

interface LogStyles {
  debug: LogStyle & { dateCss: string[] }
  log: LogStyle
  info: LogStyle
  warn: LogStyle
  error: LogStyle
}

const styles: LogStyles = {
  debug: {
    icon: "🐛",
    css: [],
    dateCss: ["float: right"],
  },
  log: {
    icon: "🪵",
    css: [],
  },
  info: {
    icon: "💡",
    css: ["color: blue"],
  },
  warn: {
    icon: "⚠️",
    css: [
      "color: #63450b",
      "background-color: #fffbe5",
      "border: 1px solid #fff5c2",
      "padding: 0.5em",
    ],
  },
  error: {
    icon: "❌",
    css: [
      "color: #ff1a1a",
      "background-color: #fff0f0",
      "border: 1px solid #ffd6d6",
      "padding: 0.5em",
    ],
  },
}

function purgeSensitiveFailSafe(log: string): string {
  // 1. Hexadecimal segments
  // 2. Private key length segments
  // 3. Lowercase groups of 12 words, which therefore covers 24

  return log.replaceAll(
    /0x[0-9a-fA-F]+|(\b[a-zA-Z0-9]{64}\b)|(?:[a-z]+(?:\s|$)){12}/g,
    "[REDACTED]",
  )
}

const BLINK_PREFIX = "    at "
const WEBKIT_GECKO_DELIMITER = "@"
const WEBKIT_MARKER = "@"
const GECKO_MARKER = "/"

function logLabelFromStackEntry(
  stackEntry: string | undefined,
): string | undefined {
  // Blink-ish.
  if (stackEntry?.startsWith(BLINK_PREFIX)) {
    // "    at [Class.][function] (... source file ...)
    return stackEntry.substring(BLINK_PREFIX.length).split(" ")[0]
  }

  // Fall back to Gecko-ish.
  if (
    stackEntry?.includes(GECKO_MARKER) &&
    stackEntry.includes(WEBKIT_GECKO_DELIMITER)
  ) {
    // "[path/to/Class/]method<?[/internal<?]@(... source file ...)"
    return stackEntry
      .split(WEBKIT_GECKO_DELIMITER)[0]
      .split(GECKO_MARKER)
      .filter((item) => item.replace(/(?:promise)?</, "").trim() !== "")
      .slice(-2)
      .join(".")
  }

  // WebKit-ish.
  if (stackEntry?.includes(WEBKIT_MARKER)) {
    // "[function]@(... source ...)
    return stackEntry.split(WEBKIT_MARKER)[0]
  }

  return undefined
}

// The length of an ISO8601 date string.
const iso8601Length = 24
// A regular expression that matches ISO8601 date strings as generated by
// `Date.toISOString()`.
const iso8601DateRegExpString =
  "\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d+(?:[+-][0-2]\\d:[0-5]\\d|Z)"
// Matches start-of-line ISO dates in brackets with a space and bracket
// following, matching our serliazed logging pattern. A zero-width positive
// lookahead is used so that a split using this regex will not consume the date
// when splitting.
const logDateRegExp = new RegExp(
  `(?=^\\[${iso8601DateRegExpString}\\] \\[)`,
  "m",
)

class Logger {
  /**
   * Minimum level to output
   */
  #level = logLevels[LogLevel.debug]

  set logLevel(value: LogLevel) {
    this.#level = logLevels[value]
  }

  private store = store

  constructor(public contextId: string = "BG") {}

  debug(...input: unknown[]): void {
    this.logEvent(LogLevel.debug, input)
  }

  info(...input: unknown[]): void {
    this.logEvent(LogLevel.info, input)
  }

  warn(...input: unknown[]): void {
    this.logEvent(LogLevel.warn, input)
  }

  error(...input: unknown[]): void {
    this.logEvent(LogLevel.error, input)
  }

  /**
   * Helper for a common pattern where `Promise.allSettled` is used to
   * run multiple promises in parallel to resolve data, and some of the
   * promises may fail and should be logged as such.
   *
   * If no promises fail, nothing is logged. If promises fail, the
   * promise is logged alongside the corresponding entry in
   * `perResultData`, if any. This allows for `perResultData` to include
   * additional information about the failed promise. For example, if the
   * promises are resolving information about a set of addresses, the
   * perResultData might include the address corresponding to each promise, so
   * that a failure to resolve information about an address can include that
   * address alongside the failure for further debugging.
   */
  errorLogRejectedPromises<T>(
    logPrefix: string,
    settledPromises: PromiseSettledResult<T>[],
    perResultData: unknown[] = [],
  ): void {
    const rejectedLogData = settledPromises.reduce<unknown[]>(
      (logData, settledPromise, i) =>
        settledPromise.status === "rejected"
          ? [...logData, settledPromise, ...perResultData.slice(i, i + 1)]
          : logData,
      [],
    )

    if (rejectedLogData.length > 0) {
      this.error(logPrefix, rejectedLogData)
    }
  }

  buildError(...input: unknown[]): Error {
    this.error(...input)
    return new Error(input.join(" "))
  }

  logEvent(level: Exclude<LogLevel, LogLevel.off>, input: unknown[]): void {
    if (logLevels[level] < this.#level) return

    const stackTrace = new Error().stack
      ?.split("\n")
      ?.filter((line) => {
        // Remove empty lines from the output
        // Chrome prepends the word "Error" to the first line of the trace, but Firefox doesn't
        // Let's ignore that for consistency between browsers!
        if (line.trim() === "" || line.trim() === "Error") {
          return false
        }

        return true
      })
      // The first two lines of the stack trace will always be generated by this
      // file, so let's ignore them.
      ?.slice(2)

    const logLabel =
      logLabelFromStackEntry(stackTrace?.[0]) ?? "(unknown function)"
    const isoDateString = new Date().toISOString()
    const [logDate, logTime] = isoDateString.replace(/Z$/, "").split(/T/)

    console.group(
      `%c ${styles[level].icon} [${logTime}] ${logLabel} %c [${logDate}]`,
      styles[level].css.join(";"),
      styles[level].dateCss ?? styles.debug.dateCss.join(";"),
    )

    console[level](...input)

    // Suppress displaying stack traces when we use console.error()
    // since the browser already does that
    if (typeof stackTrace !== "undefined" && level !== "error") {
      console[level](stackTrace.join("\n"))
    }

    console.groupEnd()

    this.saveLog(level, isoDateString, logLabel, input, stackTrace)
  }

  private async saveLog(
    level: LogLevel,
    isoDateString: string,
    logLabel: string,
    input: unknown[],
    stackTrace: string[] | undefined,
  ) {
    const formattedInput = input
      .map((loggedValue) => {
        if (typeof loggedValue === "object") {
          try {
            return JSON.stringify(loggedValue)
          } catch (_) {
            // If we can't stringify thats OK, we'll still see [object Object] or
            // null in the logs.
            return String(loggedValue)
          }
        } else {
          return String(loggedValue)
        }
      })
      .join(" ")

    const formattedStackTrace =
      stackTrace === undefined ? "" : `\n${stackTrace.join("\n")}`

    // Indent formatted input under the parent.
    const logData = `    ${formattedInput}${formattedStackTrace}`
      .split("\n")
      .join("\n    ")

    const existingLogs = await this.store.get(level)

    const fullPrefix = `[${isoDateString}] [${level.toUpperCase()}:${
      this.contextId
    }]`

    // Note: we have to do everything from here to `store.set`
    // synchronously, i.e. no promises, otherwise we risk losing logs between
    // background and content/UI scripts.
    const purgedData = purgeSensitiveFailSafe(logData)
    const updatedLogs =
      `${existingLogs}${fullPrefix} ${logLabel}\n${purgedData}\n\n`
        // Restrict each log level to hold the last 50k characters to avoid excess resource
        // usage.
        .slice(-50000)

    await this.store.set(level, updatedLogs)
  }

  async serializeLogs(): Promise<string> {
    type StoredLogData = {
      -readonly [level in Exclude<LogLevel, LogLevel.off>]: string
    }

    const logs: StoredLogData = await this.store.getAll(
      "debug",
      "info",
      "warn",
      "error",
    )

    if (Object.values(logs).every((entry) => entry === "")) {
      return "[NO LOGS FOUND]"
    }

    const logEntries = Object.values(logs).flatMap((levelLogs) => {
      const splitLogs = levelLogs?.split(logDateRegExp) ?? []
      // If the date of the first element got cut off, use the 0 date for it.
      if (
        splitLogs.length > 0 &&
        splitLogs[0] !== "" &&
        !splitLogs[0].match(logDateRegExp)
      ) {
        splitLogs[0] = `[${new Date(0).toISOString()}] ${splitLogs[0]}`
      }

      return splitLogs
    })

    // This check is here to safeguard array access in the filter below
    if (logEntries.length < 1) {
      return ""
    }

    const dateFromLogEntry = (dateStr: string) =>
      new Date(dateStr.substring(1, iso8601Length))

    const entriesByDateAsc = logEntries
      // Sort by date.
      .sort((a, b) =>
        a.substr(1, iso8601Length).localeCompare(b.substr(1, iso8601Length)),
      )

    const lastEntry = entriesByDateAsc[entriesByDateAsc.length - 1]
    const lastEntryDate = dateFromLogEntry(lastEntry)

    return entriesByDateAsc // Only grab logs from the last available hour
      .filter(
        (logLine) =>
          dateFromLogEntry(logLine).getTime() >= lastEntryDate.getTime() - HOUR,
      )
      .join("\n")
  }
}

const logger = new Logger()

/**
 * Takes the same parameters as `Logger.errorLogRejectedPromises` and logs any
 * failed promises by calling that.
 *
 * This helper also unwraps all *fulfilled* promises and returns the results. That
 * allows a caller who wants to log failures and then deal with successes in one
 * step to call this function once with the `await Promise.allSettled` call as the
 * second parameter, and assign the result directly to a variable that is used
 * in downstream work.
 */
export function logRejectedAndReturnFulfilledResults<T>(
  logPrefix: string,
  settledPromises: PromiseSettledResult<T>[],
  perResultData: unknown[] = [],
): T[] {
  logger.errorLogRejectedPromises(logPrefix, settledPromises, perResultData)

  return settledPromises.reduce<T[]>(
    (fulfilledResults, settledPromise) =>
      settledPromise.status === "fulfilled"
        ? [...fulfilledResults, settledPromise.value]
        : fulfilledResults,
    [],
  )
}

export const serializeLogs = logger.serializeLogs.bind(logger)

export default logger
